fix(ui): correct root-canvas demotion state and listener bookkeeping#3070
fix(ui): correct root-canvas demotion state and listener bookkeeping#3070cptbtptpbcptdtptp wants to merge 1 commit into
Conversation
Three coupled fixes ported from galacean#3068 (fix/shaderlab): - The demote loop dirtied the canvas itself instead of each element (setRootCanvasDirty(this) -> (element)), so demoted elements kept stale _rootCanvas/_indexInRootCanvas and never re-homed: the new root's walk re-assignment is gated on the element's dirty flag. - _registerListener truncated a shrinking listening chain without unregistering the dropped tail, leaking listeners on ex-ancestors. - Releasing that leak exposed that nested-canvas demotion relied on it: UpdateFlagManager.dispatch iterates a backward snapshot, so a listener appended mid-dispatch is never invoked by that dispatch, and the enabling canvas claims root status only after its dispatch returns — the cascade's search could not find it and promoted nested canvases to root. The demote cascade now receives the enabling canvas as an explicit successor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
WalkthroughChangesListener cleanup
Root canvas transitions
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant CanvasEnableInScene
participant UICanvas
participant Utils
CanvasEnableInScene->>UICanvas: Pass enabling canvas as successor
UICanvas->>Utils: Search parent root canvas
Utils-->>UICanvas: Return parent root or successor fallback
UICanvas->>UICanvas: Propagate root status through nested elements
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
ESLint install timed out. The project may have too many dependencies for the sandbox. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## dev/2.0 #3070 +/- ##
===========================================
- Coverage 79.82% 79.82% -0.01%
===========================================
Files 904 904
Lines 101297 101309 +12
Branches 11423 11421 -2
===========================================
+ Hits 80865 80867 +2
- Misses 20250 20260 +10
Partials 182 182
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
🧹 Nitpick comments (1)
tests/src/ui/UICanvas.test.ts (1)
354-389: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd a second element to prevent hiding concurrent modification bugs.
Testing the demote cascade with only a single nested element (
image) masks concurrent modification bugs (such as removing elements from an array while looping over it, which succeeds for length 1 but skips elements for length 2+). Adding a second element ensures the logic robustly processes all children and would successfully catch theforEachiteration bug inUICanvas.ts.♻️ Proposed test update
it("re-homes elements to the new root canvas when theirs loses root status", () => { const outerEntity = root.createChild("outer"); const innerEntity = outerEntity.createChild("inner"); const innerCanvas = innerEntity.addComponent(UICanvas); const imageEntity = innerEntity.createChild("image"); const image = imageEntity.addComponent(Image); + const image2Entity = innerEntity.createChild("image2"); + const image2 = image2Entity.addComponent(Image); // Collected by the inner canvas while it is the root. // `@ts-ignore` innerCanvas._getRenderers(); // `@ts-ignore` expect(image._getRootCanvas()).to.eq(innerCanvas); + // `@ts-ignore` + expect(image2._getRootCanvas()).to.eq(innerCanvas); // `@ts-ignore` expect(innerCanvas._disorderedElements.length).to.be.greaterThan(0); // Enabling a canvas on an ancestor demotes the inner one. const outerCanvas = outerEntity.addComponent(UICanvas); // `@ts-ignore` expect(innerCanvas._isRootCanvas).to.be.false; // `@ts-ignore` expect(outerCanvas._isRootCanvas).to.be.true; // The element's canvas state must be reset (the walk's re-assignment is gated on the // dirty flag), otherwise it keeps rendering into the demoted canvas's dead queue. // `@ts-ignore` expect(image._isRootCanvasDirty).to.be.true; // `@ts-ignore` expect(image._rootCanvas).to.be.null; + // `@ts-ignore` + expect(image2._isRootCanvasDirty).to.be.true; + // `@ts-ignore` + expect(image2._rootCanvas).to.be.null; // The new root adopts it on its next walk. // `@ts-ignore` outerCanvas._getRenderers(); // `@ts-ignore` expect(image._getRootCanvas()).to.eq(outerCanvas); + // `@ts-ignore` + expect(image2._getRootCanvas()).to.eq(outerCanvas); outerEntity.destroy(); });🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/src/ui/UICanvas.test.ts` around lines 354 - 389, Add a second child UI element with an Image component in the test “re-homes elements to the new root canvas when theirs loses root status,” then assert both images are marked dirty, have null root canvases after demotion, and are reassigned to outerCanvas after its renderer walk. Keep the existing assertions and cleanup behavior intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@tests/src/ui/UICanvas.test.ts`:
- Around line 354-389: Add a second child UI element with an Image component in
the test “re-homes elements to the new root canvas when theirs loses root
status,” then assert both images are marked dirty, have null root canvases after
demotion, and are reassigned to outerCanvas after its renderer walk. Keep the
existing assertions and cleanup behavior intact.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: fdadae4d-fb3c-4d95-a316-e04f616e96b7
📒 Files selected for processing (3)
packages/ui/src/Utils.tspackages/ui/src/component/UICanvas.tstests/src/ui/UICanvas.test.ts
GuoLei1990
left a comment
There was a problem hiding this comment.
总结
把 #3068 的三处 packages/ui 修复原样 backport 到 dev/2.0。我在 #3068 已逐轮审过这三处(round-3 setRootCanvasDirty(this)→(element) 的 #2375-class bug、round-6 listener tail-release 泄漏、round-7 hosting 状态机 successor 线程化),本轮核心是校验:前像 bug 真在 dev/2.0 上、修复与 #3068 逐字等价、类型适配到 mainline ui 接口名正确、两个新测试可反向证伪。全部通过,无 P0/P1/P2/P3,可合。
核验结论(逐条)
1. Utils.ts — listener 链缩短的 tail 泄漏
dev/2.0前像确认:_registerListener走完 while 后直接listeningEntities.length = count,链变短时[count, oldLength)的尾部实体仍挂着_registerModifyListener→ 永久泄漏。- 修复补的
for (i=count; i<n) listeningEntities[i]._unRegisterModifyListener(listener)与 #3068 tip3c6c88c0逐字节相同(含注释)。✓
2. UICanvas.ts:414 — CanvasEnableInScene 分支缺 successor
- 前像
_setIsRootCanvas(false)无 successor → 嵌套 canvas demote cascade 里searchRootCanvasInParents(element)看不到「刚 enable、尚未 claim root」的祖先 canvas → 嵌套 canvas 被误提升为 root。 - 修复
_setIsRootCanvas(false, <UICanvas>param)+_setIsRootCanvas新增successor形参、cascade 里searchRootCanvasInParents(element) ?? successor,逻辑与 #3068 等价(#3068 用IUIElement/IUIGroupAble是它 round-5 把接口上移 core 后的名字,dev/2.0无此重构,backport 正确沿用 mainline 的IElement/IGroupAble,行为一致)。✓
3. UICanvas.ts:656 — 非 canvas 元素 setRootCanvasDirty(this)→(element)
- 前像 dirty 的是「正在 demote 的 canvas 自己」,而紧接着
disorderedElements.length = 0把这张 canvas 的列表清空 → 对元素 re-home 是 no-op;元素保留 stale_rootCanvas/_indexInRootCanvas+ 干净的 dirty flag,新 root 的 walk(以元素 dirty flag 为门)跳过它 → 渲染进已死队列、永不 re-home。 - 修复改为 dirty
element本身(setRootCanvasDirty内部置_isRootCanvasDirty=true+_registerRootCanvas(element,null)把_rootCanvas清 null),正解。✓
测试(两个新增均可反向证伪)
re-homes elements...:断言image._isRootCanvasDirty===true+image._rootCanvas===null。前像走setRootCanvasDirty(this)→ image 状态不动 →_isRootCanvasDirty仍 false → 前像 fail。✓re-roots nested canvases...:断言inner._isRootCanvas===false+inner._getRootCanvas()===outerCanvas。前像无 successor →searchRootCanvasInParents(inner)返 null →_setIsRootCanvas(!null)把 inner 误提升为 root → 断言 fail。✓Image经component/index.ts:6→index.ts export * from "./component"导出,IElement在dev/2.0声明了_rootCanvas/_indexInRootCanvas/_isRootCanvasDirty/_getRootCanvas(),测试引用符号全部存在。✓
补充说明
- #3068 里的「ui/Utils + spine 双源同步」债在此不适用:
dev/2.0无 spine 包(spine-in-UICanvas 是 #3068 stacked 在fix/shaderlab上的工作),backport 只需改ui/Utils.ts一处,已完整。 - 新增注释与 #3068 已接受版本逐字相同(多句解释性
//块,非 JSDoc),风格随 ported 代码一致,不另提。
LGTM。
Summary
Ports three coupled
packages/uifixes from #3068 (fix/shaderlab) todev/2.0— all three bug sites are byte-identical here.UICanvas._setIsRootCanvas(false)calledUtils.setRootCanvasDirty(this)instead of(element), so demoted elements kept a stale_rootCanvas/_indexInRootCanvasand a clean dirty flag — but the new root's walk re-assignment is gated on the element's dirty flag, so they kept rendering into the demoted canvas's dead queue and never re-homed.Utils._registerListenertruncated a shrinking listening chain (listeningEntities.length = count) without unregistering the dropped tail, permanently leaking listeners on ex-ancestor entities.UpdateFlagManager.dispatchiterates a backward snapshot (a listener appended mid-dispatch is never invoked by that dispatch), and the enabling canvas claims root status only after itsCanvasEnableInScenedispatch returns — so the demote cascade'ssearchRootCanvasInParentscould not find it and promoted nested canvases to root. Convergence previously depended on a leaked listener happening to sit at a low index of the backward iteration. The cascade now receives the enabling canvas as an explicitsuccessor(_setIsRootCanvas(false, successor)).(2) and (3) must land together: fixing the leak alone breaks nested-canvas re-rooting.
Tests
Two regression tests added to
tests/src/ui/UICanvas.test.ts:re-homes elements to the new root canvas when theirs loses root status— pins (1)re-roots nested canvases to the enabling ancestor canvas— pins (3), exercised through (2)Negative validation: on unfixed
dev/2.0(rebuilt dist without the source fixes) both new tests fail and all 6 existing tests pass; with the fixes the full ui suite is green (66/66, node22b:module+b:types, vitest fromtests/).🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
Tests